📥 下载字模工具: 点击下载 PC2LCD2021.exe
以下为懒人专属!
📥 下载font.c: 点击下载 font.c
📥 下载font.h: 点击下载 font.h
同学!你已经成功点亮了屏幕并且能显示纯色,还用了DMA加速,这说明你的基础驱动已经非常扎实了。现在我们要让屏幕“说话”——显示字符和文字。这其实就像在屏幕上“画”出字母的形状,而字模工具就是帮我们把字母形状翻译成“点阵地图”的翻译官。
下面我会用最通俗的方式,一步步带你写出显示字符的代码,并且把原理讲透,让你不仅会抄代码,更明白为什么这么写。
一、先理解字模是什么(像素小灯泡的开关图)
字模工具界面(.图)
想象一下,你的屏幕是由很多个微小的彩色小灯泡(像素)组成的。要显示一个字母“A”,我们需要决定哪些小灯泡亮(前景色),哪些不亮(背景色)。
字模文件就是一个“开关图”:它用二进制位(0或1)记录了每个位置的小灯泡是亮(1)还是灭(0)。
比如一个宽8像素、高16像素的字符,就是8列 × 16行 = 128个小灯泡,每个灯泡用1个bit表示,总共128bit = 16个字节。

你下载的 **PCtoLCD2021 工具生成的就是这种数组。你给的例子:
unsigned char fontArray[] =
{
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // 空格
0x00,0x00,0x00,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x00,0x00,0x10,0x10,0x00,0x00, // '!'
...
};问题来了:这16个字节是如何对应到8×16个点的呢?
通常,字模工具采用 “列行式” 排列:
- 第一个字节表示 第0列 的从上到下的8个点(因为高度16,所以分上下两半,每列用2个字节,先上半8位,再下半8位?但这里宽度8,高度16,每个字符16字节,所以很可能是每列用2个字节(共8列),每列从上到下16个点。
- 但是观察'!'的第二个字节:
0x00,0x00,0x00,0x10,0x10,...,如果是每列2字节,那么第0列是0x00,0x00(全灭),第1列是0x00,0x10? 这不太像。更常见的格式是 “每列一个字节,但高度为8”,而高度16则用两行(上下各8像素)表示。
我们来看'!'的数据:
0x00,0x00,0x00,0x10,0x10,0x10,0x10,0x10,0x10,0x10,0x00,0x00,0x10,0x10,0x00,0x00如果按 每列一个字节(8位),需要16列才能表示16行,但宽度是8,所以不对。
如果按 每行一个字节(8位),需要16行,每行8位,正好16个字节。那么每个字节表示一行中8个像素的亮灭(从左到右)。
看'!'的数据:
- 第0行:0x00 -> 全灭
- 第1行:0x00 -> 全灭
- 第2行:0x00 -> 全灭
- 第3行:0x10 -> 二进制 00010000,也就是第4个像素亮(从0开始数第4位)
- 第4行:0x10 -> 同上
- ...一直到第9行都是0x10,说明中间有一条竖线。
- 第10行:0x00
- 第11行:0x00
- 第12行:0x10
- 第13行:0x10
- 第14行:0x00
- 第15行:0x00
所以很明显:每个字节表示一行(从上到下共16行),每个字节的8个位表示从左到右的8个像素。高位(bit7)对应最左边的像素,低位(bit0)对应最右边的像素。
这就是“横向取模”方式,非常直观。

二、如何显示一个字符(我们的作战计划)
2.1 认识ASCII码表
ASCII表:32~126(共95个)是字符(32是空格),其中48~57为0到9十个阿拉伯数字。
| Bin (二进制) | Oct (八进制) | Dec (十进制) | Hex (十六进制) | 缩写/字符 | 解释 |
|---|---|---|---|---|---|
| 0000 0000 | 00 | 0 | 0x00 | NUL(null) | 空字符 |
| 0000 0001 | 01 | 1 | 0x01 | SOH(start of headline) | 标题开始 |
| 0000 0010 | 02 | 2 | 0x02 | STX (start of text) | 正文开始 |
| 0000 0011 | 03 | 3 | 0x03 | ETX (end of text) | 正文结束 |
| 0000 0100 | 04 | 4 | 0x04 | EOT (end of transmission) | 传输结束 |
| 0000 0101 | 05 | 5 | 0x05 | ENQ (enquiry) | 请求 |
| 0000 0110 | 06 | 6 | 0x06 | ACK (acknowledge) | 收到通知 |
| 0000 0111 | 07 | 7 | 0x07 | BEL (bell) | 响铃 |
| 0000 1000 | 010 | 8 | 0x08 | BS (backspace) | 退格 |
| 0000 1001 | 011 | 9 | 0x09 | HT (horizontal tab) | 水平制表符 |
| 0000 1010 | 012 | 10 | 0x0A | LF (NL line feed, new line) | 换行键 |
| 0000 1011 | 013 | 11 | 0x0B | VT (vertical tab) | 垂直制表符 |
| 0000 1100 | 014 | 12 | 0x0C | FF (NP form feed, new page) | 换页键 |
| 0000 1101 | 015 | 13 | 0x0D | CR (carriage return) | 回车键 |
| 0000 1110 | 016 | 14 | 0x0E | SO (shift out) | 不用切换 |
| 0000 1111 | 017 | 15 | 0x0F | SI (shift in) | 启用切换 |
| 0001 0000 | 020 | 16 | 0x10 | DLE (data link escape) | 数据链路转义 |
| 0001 0001 | 021 | 17 | 0x11 | DC1 (device control 1) | 设备控制1 |
| 0001 0010 | 022 | 18 | 0x12 | DC2 (device control 2) | 设备控制2 |
| 0001 0011 | 023 | 19 | 0x13 | DC3 (device control 3) | 设备控制3 |
| 0001 0100 | 024 | 20 | 0x14 | DC4 (device control 4) | 设备控制4 |
| 0001 0101 | 025 | 21 | 0x15 | NAK (negative acknowledge) | 拒绝接收 |
| 0001 0110 | 026 | 22 | 0x16 | SYN (synchronous idle) | 同步空闲 |
| 0001 0111 | 027 | 23 | 0x17 | ETB (end of trans. block) | 结束传输块 |
| 0001 1000 | 030 | 24 | 0x18 | CAN (cancel) | 取消 |
| 0001 1001 | 031 | 25 | 0x19 | EM (end of medium) | 媒介结束 |
| 0001 1010 | 032 | 26 | 0x1A | SUB (substitute) | 代替 |
| 0001 1011 | 033 | 27 | 0x1B | ESC (escape) | 换码(溢出) |
| 0001 1100 | 034 | 28 | 0x1C | FS (file separator) | 文件分隔符 |
| 0001 1101 | 035 | 29 | 0x1D | GS (group separator) | 分组符 |
| 0001 1110 | 036 | 30 | 0x1E | RS (record separator) | 记录分隔符 |
| 0001 1111 | 037 | 31 | 0x1F | US (unit separator) | 单元分隔符 |
| ==0010 0000== | ==040== | ==32== | ==0x20== | ==(space)== | ==空格== |
| 0010 0001 | 041 | 33 | 0x21 | ! | 叹号 |
| 0010 0010 | 042 | 34 | 0x22 | " | 双引号 |
| 0010 0011 | 043 | 35 | 0x23 | # | 井号 |
| ........ | ..... | ........ | ......... | ........ | ...... |
| 0111 1101 | 0175 | 125 | 0x7D | } | 闭花括号 |
| ==0111 1110== | ==0176== | ==126== | ==0x7E== | ==~== | ==波浪号== |
| 0111 1111 | 0177 | 127 | 0x7F | DEL (delete) | 删除 |
ASCII码表(.表格)

2.2 我们要在屏幕上指定位置(x, y)画一个字符,步骤是:
计算字符在字模数组中的起始位置
你的 idxArray 是" !"#$%&'...,空格是第一个字符(序号0),'!'是第二个(序号1)。所以字符 ch 的序号 = ch - ' '(因为空格ASCII码32,'!'是33,相减得1)。
每个字符占用16个字节,所以offset = (ch - ' ') * 16,然后从 fontArray[offset] 开始取16个字节。遍历这16个字节(即16行)
对于每一行row(0~15),取出该行的字节byte = fontArray[offset + row]。遍历该字节的8个位(即8个像素)
对于列col(0~7),检查byte的第col位(注意:最左边是最高位,即 bit7)。可以用byte & (0x80 >> col)判断该位是否为1。如果该位为1,就在屏幕位置 (x + col, y + row) 画前景色;否则画背景色(或者不画,保留背景)
你可以选择画背景色(如黑色)覆盖,也可以只画前景色(背景色事先清空)。初学者建议用“画点”函数,逐步来。循环结束后,这个字符就显示出来了。

三、编写代码(手把手)
第一步:把字模数据放到你的工程里
你可以把生成的数组复制到一个 .h 文件,比如 font.h,然后在 main.c 中 #include "font.h"。
注意:你生成的数组可能叫 fontArray 和 idxArray,确保它们的名称一致。
第二步:写一个绘制单个字符的函数
在 main.c 中添加如下函数:
/**
* @brief 在指定位置绘制一个字符(8x16像素)
* @param x 左上角x坐标(列)
* @param y 左上角y坐标(行)
* @param ch 要显示的字符,如 'A'
* @param fg 前景色(RGB565)
* @param bg 背景色(RGB565),如果你只想画前景,可以设为透明,但简单起见我们画背景
* @param spi SPI设备句柄
*/
void lcd_draw_char(uint16_t x, uint16_t y, char ch, uint16_t fg, uint16_t bg, spi_device_handle_t spi)
{
// 1. 检查字符是否在可打印范围内(32~126)
if (ch < ' ' || ch > '~') return;
// 2. 计算字符在字模数组中的偏移(每个字符16字节)
int offset = (ch - ' ') * 16;
// 3. 遍历16行
for (int row = 0; row < 16; row++) {
uint8_t byte = fontArray[offset + row]; // 取出这一行的位图
// 4. 遍历8列(从左到右)
for (int col = 0; col < 8; col++) {
// 判断该位是否为1:从最高位(bit7)开始,向右移位
if (byte & (0x80 >> col)) {
// 点亮:画前景色
lcd_draw_pixel(x + col, y + row, fg, spi);
} else {
// 熄灭:画背景色(如果你想保留背景,可以不画,但为了清晰我们画背景)
lcd_draw_pixel(x + col, y + row, bg, spi);
}
}
}
}解释:
0x80 >> col可以依次得到 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01,分别对应第0~7位(高位到低位)。- 因为我们的字模是“横向取模”,每个字节的高位对应左边像素,所以这样用。

第三步:写一个显示字符串的函数
/**
* @brief 显示一个字符串,自动换行(简单版,不换行)
* @param x 起始x
* @param y 起始y
* @param str 字符串
* @param fg 前景色
* @param bg 背景色
* @param spi SPI句柄
*/
void lcd_draw_string(uint16_t x, uint16_t y, const char *str, uint16_t fg, uint16_t bg, spi_device_handle_t spi)
{
while (*str) {
// 如果字符超出屏幕宽度(假设240宽度,每个字符8像素,我们可以选择折行,但简单起见只画能显示的)
if (x + 8 > 240) {
// 换行:x回到0,y增加16(字符高度)
x = 0;
y += 16;
// 如果y超出屏幕高度则停止
if (y + 16 > 320) break;
}
lcd_draw_char(x, y, *str, fg, bg, spi);
x += 8; // 字符宽度8像素
// 可加一个像素的间隔,如 x += 9;
str++;
}
}
第四步:在 app_main 中调用
在你的 app_main 中,初始化屏幕后,调用显示字符串:
void app_main(void)
{
// ... 你的原有初始化代码(GPIO、SPI、st7789_init)...
// 清屏(可选),用黑色背景
lcd_fill_color_dma(0x0000, spi_device_handle); // 黑色
// 显示一句英文
lcd_draw_string(10, 20, "Hello, ESP32!", 0xFFFF, 0x0000, spi_device_handle);
lcd_draw_string(10, 50, "ABC 123", 0x07E0, 0x0000, spi_device_handle); // 绿色前景
// 保持显示
while (1) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
}四、可能遇到的问题及调试
字符显示倒置或镜像
如果显示出来是反的(左右镜像),你可以调整位判断方向,比如用byte & (1 << col)而不是0x80 >> col;如果是上下颠倒,那就调整 row 的遍历顺序。但按照你的字模(每个字节高位对应左边),上述代码应该是正确的。背景色覆盖
我用了bg颜色画每个像素,这会导致字符显示在纯色背景上。如果你希望背景透明(即只画前景,背景不变),那就把else部分去掉,只画前景。但要注意屏幕原本可能有杂色,所以最好先清屏。性能问题
lcd_draw_pixel每画一个像素都要设置窗口并发送SPI数据,非常慢(显示一个字符需要 8×16=128 次SPI传输)。这只是教学用,让你理解原理。后续你可以优化:- 用DMA一次性发送一行的8个像素(甚至整行),类似你图片显示的方式。
- 或者先构建一个显存缓冲区,然后一次性DMA发送。
五、进阶优化思路(等你熟练后再看)
- 使用窗口+批量发送:比如画一个字符时,设置窗口为 (x, y) 到 (x+7, y+15),然后像图片那样一次性发送 16×8×2 = 256 字节(每个像素2字节颜色)。需要把字模的位转换成颜色数据(前景色或背景色)。这比挨个画点快得多。
- 使用DMA:你可以分配一个缓冲区,填充好颜色数据,然后用
spi_device_transmit一次发送,就和显示图片一样。
但作为小白,先掌握逐个画点的方法,理解原理后再优化也不迟。
六、核心要点总结(形象比喻)
- 字模 = 一张“亮灭地图”,用二进制0/1记录每个小灯泡要不要亮。
- 显示字符 = 拿着这张地图,走到屏幕的某个位置,按照地图上每个点的指示,点亮或熄灭对应位置的灯泡。
- 偏移计算 = 找到你要的字符的地图在仓库(数组)里的位置,因为每个地图大小固定(16字节),所以用序号×16即可。
- 位操作 = 检查地图上某一点是1还是0,我们使用
&和移位来取出特定位。
现在,尝试把你的字模数组添加进去,编译烧写,看看屏幕上有没有出现“Hello, ESP32!”吧!如果出现乱码,检查字模格式(方向、位序)是否正确,调整一下位判断的方向即可。
加油!你已经从“点亮屏幕”进阶到“让屏幕说话”了!🎉
